import {spawn} from 'node:child_process'
import {readFile} from 'node:fs/promises'
import {fileURLToPath, URL} from 'node:url'

import npmCommandExists from 'command-exists'
import fetch from 'node-fetch'

export function promisifyProcess(proc, showLogging = true) {
  // Takes a process (from the child_process module) and returns a promise
  // that resolves when the process exits (or rejects, if the exit code is
  // non-zero).

  return new Promise((resolve, reject) => {
    if (showLogging) {
      proc.stdout.pipe(process.stdout)
      proc.stderr.pipe(process.stderr)
    }

    proc.on('exit', code => {
      if (code === 0) {
        resolve()
      } else {
        reject(code)
      }
    })
  })
}

export async function commandExists(command) {
  // When the command-exists module sees that a given command doesn't exist, it
  // throws an error instead of returning false, which is not what we want.

  try {
    return await npmCommandExists(command)
  } catch(err) {
    return false
  }
}

export async function killProcess(proc) {
  // Windows is stupid and doesn't like it when we try to kill processes.
  // So instead we use taskkill! https://stackoverflow.com/a/28163919/4633828

  if (await commandExists('taskkill')) {
    await promisifyProcess(
      spawn('taskkill', ['/pid', proc.pid, '/f', '/t']),
      false
    )
  } else {
    proc.kill()
  }
}

export function downloadPlaylistFromURL(url) {
  return fetch(url).then(res => res.text())
}

export function downloadPlaylistFromLocalPath(path) {
  return readFile(path).then(buf => buf.toString())
}

export function downloadPlaylistFromOptionValue(arg) {
  let argURL
  try {
    argURL = new URL(arg)
  } catch (err) {
    // Definitely not a URL.
  }

  if (argURL) {
    if (argURL.protocol === 'http:' || argURL.protocol === 'https:') {
      return downloadPlaylistFromURL(arg)
    } else if (argURL.protocol === 'file:') {
      return downloadPlaylistFromLocalPath(fileURLToPath(argURL))
    }
  } else {
    return downloadPlaylistFromLocalPath(arg)
  }
}

export function shuffleArray(array) {
  // Shuffles the items in an array. Returns a new array (does not modify the
  // passed array). Super-interesting post on how this algorithm works:
  // https://bost.ocks.org/mike/shuffle/

  const workingArray = array.slice(0)

  let m = array.length

  while (m) {
    let i = Math.floor(Math.random() * m)
    m--

    // Stupid lol; avoids the need of a temporary variable!
    Object.assign(workingArray, {
      [m]: workingArray[i],
      [i]: workingArray[m]
    })
  }

  return workingArray
}

export function throttlePromise(maximumAtOneTime = 10) {
  // Returns a function that takes a callback to create a promise and either
  // runs it now, if there is an available slot, or enqueues it to be run
  // later, if there is not.

  let activeCount = 0
  const queue = []

  const execute = function(callback) {
    activeCount++
    return callback().finally(() => {
      activeCount--

      if (queue.length) {
        return execute(queue.shift())
      }
    })
  }

  const enqueue = function(callback) {
    if (activeCount >= maximumAtOneTime) {
      return new Promise((resolve, reject) => {
        queue.push(function() {
          return callback().then(resolve, reject)
        })
      })
    } else {
      return execute(callback)
    }
  }

  enqueue.queue = queue

  return enqueue
}

export function getSecFromTimestamp(timestamp) {
  const parts = timestamp.split(':').map(n => parseInt(n))
  switch (parts.length) {
    case 3: return parts[0] * 3600 + parts[1] * 60 + parts[2]
    case 2: return parts[0] * 60 + parts[1]
    case 1: return parts[0]
    default: return 0
  }
}

export function getTimeStringsFromSec(curSecTotal, lenSecTotal = null, fraction = false) {
  const pad = val => val.toString().padStart(2, '0')
  const padFrac = val => Math.floor(val * 1000).toString().padEnd(3, '0')

  // We don't want to display hour counters if the total length is less
  // than an hour.
  const displayAsHours = Math.max(curSecTotal, lenSecTotal ?? 0) >= 3600

  const strings = {curSecTotal, lenSecTotal}

  let curHour = Math.floor(curSecTotal / 3600)
  let curMin = Math.floor((curSecTotal - curHour * 3600) / 60)
  let curSec = Math.floor(curSecTotal - curHour * 3600 - curMin * 60)
  let curFrac = curSecTotal % 1

  curMin = pad(curMin)
  curSec = pad(curSec)
  curFrac = padFrac(curFrac)

  if (displayAsHours) {
    strings.timeDone = `${curHour}:${curMin}:${curSec}`
  } else {
    strings.timeDone = `${curMin}:${curSec}`
  }

  if (fraction) {
    strings.timeDone += '.' + curFrac
  }

  if (typeof lenSecTotal === 'number') {
    const percentVal = (100 / lenSecTotal) * curSecTotal
    strings.percentDone = (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%'

    // Yeah, yeah, duplicate math.
    const leftSecTotal = lenSecTotal - curSecTotal
    let leftHour = Math.floor(leftSecTotal / 3600)
    let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60)
    let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60)
    let leftFrac = leftSecTotal % 1

    // Wee!
    let lenHour = Math.floor(lenSecTotal / 3600)
    let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60)
    let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60)
    let lenFrac = lenSecTotal % 1

    lenMin = pad(lenMin)
    lenSec = pad(lenSec)
    lenFrac = padFrac(lenFrac)

    leftMin = pad(leftMin)
    leftSec = pad(leftSec)
    leftFrac = padFrac(leftFrac)

    if (typeof lenSecTotal === 'number') {
      if (displayAsHours) {
        strings.timeLeft = `${leftHour}:${leftMin}:${leftSec}`
        strings.duration = `${lenHour}:${lenMin}:${lenSec}`
      } else {
        strings.timeLeft = `${leftMin}:${leftSec}`
        strings.duration = `${lenMin}:${lenSec}`
      }

      if (fraction) {
        strings.timeLeft += '.' + leftFrac
        strings.duration += '.' + lenFrac
      }
    }
  }

  return strings
}

export function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) {
  // Multiplication casts to numbers; addition prioritizes strings.
  // Thanks, JavaScript!
  const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec)
  const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec)

  return getTimeStringsFromSec(curSecTotal, lenSecTotal)
}

export async function parseOptions(options, optionDescriptorMap) {
  // This function is sorely lacking in comments, but the basic usage is
  // as such:
  //
  // options is the array of options you want to process;
  // optionDescriptorMap is a mapping of option names to objects that describe
  // the expected value for their corresponding options.
  // Returned is a mapping of any specified option names to their values, or
  // a process.exit(1) and error message if there were any issues.
  //
  // Here are examples of optionDescriptorMap to cover all the things you can
  // do with it:
  //
  // optionDescriptorMap: {
  //   'telnet-server': {type: 'flag'},
  //   't': {alias: 'telnet-server'}
  // }
  //
  // options: ['t'] -> result: {'telnet-server': true}
  //
  // optionDescriptorMap: {
  //   'directory': {
  //     type: 'value',
  //     validate(name) {
  //       // const whitelistedDirectories = ['apple', 'banana']
  //       if (whitelistedDirectories.includes(name)) {
  //         return true
  //       } else {
  //         return 'a whitelisted directory'
  //       }
  //     }
  //   },
  //   'files': {type: 'series'}
  // }
  //
  // ['--directory', 'apple'] -> {'directory': 'apple'}
  // ['--directory', 'artichoke'] -> (error)
  // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
  //
  // TODO: Be able to validate the values in a series option.

  const handleDashless = optionDescriptorMap[parseOptions.handleDashless]
  const result = {}
  for (let i = 0; i < options.length; i++) {
    const option = options[i]
    if (option.startsWith('--')) {
      // --x can be a flag or expect a value or series of values
      let name = option.slice(2).split('=')[0] // '--x'.split('=') = ['--x']
      let descriptor = optionDescriptorMap[name]
      if (!descriptor) {
        console.error(`Unknown option name: ${name}`)
        process.exit(1)
      }
      if (descriptor.alias) {
        name = descriptor.alias
        descriptor = optionDescriptorMap[name]
      }
      if (descriptor.type === 'flag') {
        result[name] = true
      } else if (descriptor.type === 'value') {
        let value = option.slice(2).split('=')[1]
        if (!value) {
          value = options[++i]
          if (!value || value.startsWith('-')) {
            value = null
          }
        }
        if (!value) {
          console.error(`Expected a value for --${name}`)
          process.exit(1)
        }
        result[name] = value
      } else if (descriptor.type === 'series') {
        if (!options.slice(i).includes(';')) {
          console.error(`Expected a series of values concluding with ; (\\;) for --${name}`)
          process.exit(1)
        }
        const endIndex = i + options.slice(i).indexOf(';')
        result[name] = options.slice(i + 1, endIndex)
        i = endIndex
      }
      if (descriptor.validate) {
        const validation = await descriptor.validate(result[name])
        if (validation !== true) {
          console.error(`Expected ${validation} for --${name}`)
          process.exit(1)
        }
      }
    } else if (option.startsWith('-')) {
      // mtui doesn't use any -x=y or -x y format optionuments
      // -x will always just be a flag
      let name = option.slice(1)
      let descriptor = optionDescriptorMap[name]
      if (!descriptor) {
        console.error(`Unknown option name: ${name}`)
        process.exit(1)
      }
      if (descriptor.alias) {
        name = descriptor.alias
        descriptor = optionDescriptorMap[name]
      }
      if (descriptor.type === 'flag') {
        result[name] = true
      } else {
        console.error(`Use --${name} (value) to specify ${name}`)
        process.exit(1)
      }
    } else if (handleDashless) {
      handleDashless(option)
    }
  }
  return result
}

parseOptions.handleDashless = Symbol()

export async function silenceEvents(emitter, eventsToSilence, callback) {
  const oldEmit = emitter.emit

  emitter.emit = function(event, ...data) {
    if (!eventsToSilence.includes(event)) {
      oldEmit.apply(emitter, [event, ...data])
    }
  }

  await callback()

  emitter.emit = oldEmit
}

// Kindly stolen from ESDiscuss:
// https://esdiscuss.org/topic/proposal-add-an-option-to-omit-prototype-of-objects-created-by-json-parse#content-1
export function parseWithoutPrototype(string) {
  return JSON.parse(string, function(k, v) {
    if (v && typeof v === 'object' && !Array.isArray(v)) {
      return Object.assign(Object.create(null), v)
    }
    return v
  })
}
